Skip to content

MB-72489: top-N collector optimizations - #2385

Merged
Thejas-bhat merged 8 commits into
masterfrom
perf-gar-v17-collector
Jul 28, 2026
Merged

MB-72489: top-N collector optimizations #2385
Thejas-bhat merged 8 commits into
masterfrom
perf-gar-v17-collector

Conversation

@maneuvertomars

@maneuvertomars maneuvertomars commented Jul 27, 2026

Copy link
Copy Markdown
Member

Top-N collector optimizations:

Changes:

  • Replace container/heap with an inline ternary heap in the top-N collector — shallower siftDown, no interface dispatch. Collector benchmarks: ~14% faster (a hand-rolled binary heap measures ~10%, benchstat, n=10).
  • collectStoreHeap.Final(): extract results via one in-place sort.Slice + sequential copy instead of repeated heap pops.
  • DocumentMatch.Reset(): zero only the fields not restored afterwards, instead of wiping the whole struct.
  • Early-stop for score=none + Size: stop scanning once Size+From hits are collected, when nothing depends on unseen matches (no facets, KNN, SearchAfter, nested, or field sort). Adds SearchResult.TotalRelation ("eq"/"gte") since Total becomes a lower bound for such requests.
  • Remove the unused collectStoreList type.

steveyen and others added 6 commits July 27, 2026 18:07
… depth

Before: container/heap (binary, log₂N comparisons per siftDown)
After:  inline ternary heap (3 children/node, log₃N comparisons, removed dependency)

The top-N collector maintains a min-heap (worst score at root) over the K best
results seen so far. Every new candidate triggers a siftDown traversal from the
root. A binary heap takes log₂(N) levels per traversal; a ternary heap takes
log₃(N):

  k=10:   binary ~3.3 levels  →  ternary ~2.1 levels  (−36%)
  k=100:  binary ~6.6 levels  →  ternary ~4.2 levels  (−37%)
  k=1000: binary ~10 levels   →  ternary ~6.3 levels  (−37%)

Comparing 3 children per step rather than 1 means all three fit in 1-2 cache
lines instead of 1 — fewer cache-line fetches per traversal at the cost of one
extra comparison per step. Net win: shallower tree beats extra comparison.

Implementation: siftUp/siftDown inline methods on collectStoreHeap using child
formula 3i+1, 3i+2, 3i+3. Removes the container/heap import entirely.
~67 lines changed; zero interface changes.

Measured improvement on M2 Pro (3-term BM25, MAXSCORE path):
  k=10:   905µs → 892µs  (~1.5%)
  k=100:  922µs → 911µs  (~1.2%)
  k=1000: 1395µs → 1354µs (~3%)

Benefit scales with k — meaningful for top-1000, small for top-10.
Composes cleanly with §1 WAND: fewer candidates reach the heap, so fewer
siftDowns overall; a faster heap multiplies whatever fraction remains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 2ed83c8)
…ion)

collectStoreHeap.Final was 11.6% of total CPU when profiling k=1000 BM25
queries. Extracting k docs via repeated removeLast (heapsort) requires
O(k log3 k) comparisons with scattered pointer dereferences at each heap
level - poor cache behavior. Fix: sort.Slice in-place (pdqsort) then copy
heap[skip..skip+size-1] sequentially. Final cum cost: 1.29s -> 0.52s (-60%).

Note: the original perf-gar commit (89fa0a1 on perf-gar-v17-only) also
specialized the score-descending comparator; that half is already on master
via MB-72489 (#2381) as getOptimalCollectorCompare, so this cherry-pick
carries only the Final() change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 89fa0a1)
Adds 9 unit tests for the collectStoreList type in search/collector that
had 0% coverage before this commit. Tests cover round-trip insertion order,
size-capped eviction (AddNotExceedingSize), skip-based pagination (Final),
Internal() ascending traversal, removeLast worst-doc eviction, single-element
and equal-score edge cases, and fixup error propagation — all using existing
scoreDesc / makeScoreDoc helpers from heap_test.go.

These tests have no dependency on any perf-gar change and can be rebased
or cherry-picked independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 7de1081)
Reset saves five backing arrays and a map, wipes the struct with
*dm = DocumentMatch{}, then restores them. That wipe is a ~240-byte duffzero, and
most of what it clears is either about to be overwritten or already zero.

Zero the nine fields that are NOT restored below instead. Same postconditions,
no duffzero.

Reset runs once per collected document, so this is on the hottest per-document
path there is — it shows up on single-term queries, not just disjunctions.

Split out of perf-gar-mod-fix-2's 5d9689f, which bundled this with a §15
minSegCeiling guard. The two are unrelated: the guard needs the per-segment score
ceilings and therefore the v18 MaxTFNorm sidecar, while this is pure in-memory work
that applies to any segment format. Only this half belongs on a v17-only branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Guard pointer/string/map field zeroing in Reset with nil/empty checks so the
common case (no explain, no fragments, no field highlights) skips nil-to-nil
stores and their GC write barriers (~5 cycles per field, across the millions
of Reset calls in a large result collection).

Note: the original perf-gar commit (1b30de4 on perf-gar-v17-only) also added
the prepareDocumentMatch fast path and the non-KNN adjustDocumentMatch guard;
both are already on master via MB-72489 (#2381) as canFastPrepare() and
adjustKNNDocumentMatch, so this cherry-pick carries only the Reset() change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
(cherry picked from commit 1b30de4)
With Score="none" and a bounded Size, the request means "return any Size+From
matching documents" — nothing about the result depends on the matches beyond that.
So the collector can stop pulling from the searcher once it has them, instead of
draining the entire match set to count it.

On a 500k-doc corpus this is the difference between scanning every match and
scanning ten: BenchmarkEarlyStopTermTier1ScoreNone goes 18.15ms -> 8.4us and
EarlyStopDisjMin2ScoreNone 41.79ms -> 22.6us on the full branch.

Because the scan really does stop counting, Total becomes a lower bound, and saying
otherwise would silently mislead callers. This adds SearchResult.TotalRelation
("eq" | "gte") to report that, plus the merge rule that any constituent reporting
"gte" makes a merged Total "gte" too.

Gated on the result being independent of unseen documents — no facets (every match
must be counted into the buckets), no KNN (separate hit set), no SearchAfter (the
cursor depends on the full ordering), no nested rollup, no reverse execution, and
sort-by-score only (which degrades to arrival order under score="none"; a field
sort could have its top-k anywhere in the match set).

Two deviations from perf-gar-mod-fix-2's version of this work, both because that
branch's WAND machinery is not present here:

  - TotalRelation is introduced here rather than in 0f56552, which bundled the
    public API with the WANDPruned collector plumbing and the disjunction-searcher
    changes that set it. On this branch the bounded scan is the only producer of a
    lower-bound Total, so the API arrives with its first user and nothing about it
    is dead.
  - index_impl derives TotalRelation from coll.EarlyStopped() alone, not
    "coll.WANDPruned() || coll.EarlyStopped()".

The upstream commit shipped no tests. Added earlystop_test.go, because the
preconditions above are the whole correctness argument and getting one wrong is a
silent wrong-answer bug rather than a slowdown: it checks that a bounded scan still
returns Size hits and reports "gte", that every returned hit is a real match, and
that the scan does NOT engage for facets, field sort, SearchAfter or scoring —
each of which must still report an exact Total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coveralls

coveralls commented Jul 27, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 52.466% (+0.1%) from 52.323% — perf-gar-v17-collector into master

@maneuvertomars maneuvertomars changed the title perf: top-N collector optimizations (perf-gar v17-only, 1/3) perf: top-N collector optimizations (from perf-gar v17-only, 1/3) Jul 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a set of performance-focused changes around the top-N collector and DocumentMatch reuse, plus an early-stop optimization for score="none" queries that can safely return any Size+From matches. To keep the observable behavior explicit, it also adds SearchResult.TotalRelation ("eq"/"gte") to indicate when Total is an exact count vs a lower bound due to bounded scanning.

Changes:

  • Replace container/heap with an inline ternary heap implementation and optimize final result extraction via a single in-place sort.Slice.
  • Optimize DocumentMatch.Reset() to avoid full-struct zeroing and skip nil→nil stores to reduce GC barrier overhead.
  • Add bounded-scan early-stop for safe score="none" + Size>0 cases, and expose the new TotalRelation field to report lower-bound totals.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
search/search.go Optimizes DocumentMatch.Reset() by targeted zeroing and guarded nil stores while preserving reusable buffers.
search/collector/topn.go Adds early-stop support to TopNCollector and exposes SetEarlyStop/EarlyStopped.
search/collector/list_test.go Adds coverage for the linked-list collector store behavior.
search/collector/heap.go Replaces stdlib binary heap with a ternary heap and optimizes Final() via in-place sorting.
search/collector/heap_test.go Adds tests for ternary heap invariants and Final() behavior.
search.go Adds TotalRelationEq/Gte constants and SearchResult.TotalRelation, and propagates the relation in Merge.
index_impl.go Enables early-stop under strict preconditions and sets TotalRelation based on whether collection bounded early.
earlystop_test.go Adds integration tests validating bounded scan behavior and the TotalRelation contract.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread search/collector/topn.go
Comment on lines +718 to +720
func (hc *TopNCollector) SetEarlyStop(n int) {
hc.earlyStopN = n
}
Comment thread search.go
Comment on lines 683 to +691
// Merge will merge together multiple SearchResults during a MultiSearch
func (sr *SearchResult) Merge(other *SearchResult) {
sr.Status.Merge(other.Status)
sr.Hits = append(sr.Hits, other.Hits...)
sr.Total += other.Total
if other.TotalRelation == TotalRelationGte {
// Any constituent whose Total is a lower bound makes the merged Total one.
sr.TotalRelation = TotalRelationGte
}
Comment on lines +153 to +155
// TestTernaryHeapFinalWithSkip verifies that Final(skip, ...) skips the skip
// worst results and returns the rest best-first.
func TestTernaryHeapFinalWithSkip(t *testing.T) {

@Thejas-bhat Thejas-bhat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please tag the associated MB-72489

Comment thread search/collector/list_test.go Outdated
Comment thread search/collector/heap.go
Comment thread search/search.go Outdated
Comment thread search/collector/topn.go Outdated
Comment thread search.go Outdated
Comment thread index_impl.go Outdated
Comment thread search/collector/topn.go Outdated
@maneuvertomars maneuvertomars changed the title perf: top-N collector optimizations (from perf-gar v17-only, 1/3) MB-72489 perf: top-N collector optimizations (from perf-gar v17-only, 1/3) Jul 28, 2026
… trim comments

- remove collectStoreList + its test (type is unused in the collector package)
- Reset(): zero fields directly without nil guards
- replace verbose early-stop comments with concise notes at the call site
Comment thread search/collector/topn.go Outdated
@Thejas-bhat Thejas-bhat changed the title MB-72489 perf: top-N collector optimizations (from perf-gar v17-only, 1/3) MB-72489: top-N collector optimizations Jul 28, 2026
@Thejas-bhat

Copy link
Copy Markdown
Member

also, i feel like the PR description is way more verbose than necessary. can you please remove the redundant information that's there, and also update it with the latest patch in mind (removing the collectorList impl for eg)

@Thejas-bhat
Thejas-bhat self-requested a review July 28, 2026 17:04

@steveyen steveyen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perf-GAR!!

@Thejas-bhat
Thejas-bhat merged commit 9d24b6b into master Jul 28, 2026
10 checks passed
@Thejas-bhat
Thejas-bhat deleted the perf-gar-v17-collector branch July 28, 2026 18:07
ns-codereview pushed a commit to couchbase/cbft that referenced this pull request Jul 29, 2026
bleve PR: blevesearch/bleve#2385

Change-Id: I5e213e49308a39f676d123b99f47336ba2412389
Reviewed-on: https://review.couchbase.org/c/cbft/+/249641
Well-Formed: Build Bot <build@couchbase.com>
Reviewed-by: <thejas.orkombu@couchbase.com>
Tested-by: Rohit Parashar <rohitp.kumar@couchbase.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants